diff --git a/Package.resolved b/Package.resolved index bc7ce25..1e2302c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -8,6 +8,15 @@ "revision" : "3dc6a42c7727c49bf26508e29b0a0b35f9c7e1ad", "version" : "5.8.1" } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "9cb486020ebf03bfa5b5df985387a14a98744537", + "version" : "1.6.1" + } } ], "version" : 2 diff --git a/Package.swift b/Package.swift index 2db7e87..7a913bc 100644 --- a/Package.swift +++ b/Package.swift @@ -42,13 +42,16 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/Alamofire/Alamofire.git", .upToNextMajor(from: "5.8.1")), + .package(url: "https://github.com/apple/swift-log.git", .upToNextMajor(from: "1.4.2")), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages this package depends on. .target( name: "MSLFoundation", - dependencies: [] + dependencies: [ + .product(name: "Logging", package: "swift-log"), + ] ), .target( name: "MSLCombine", diff --git a/Sources/MSLFoundation/BackgroundTaskManager/AsyncOperation.swift b/Sources/MSLFoundation/BackgroundTaskManager/AsyncOperation.swift new file mode 100644 index 0000000..302b0b6 --- /dev/null +++ b/Sources/MSLFoundation/BackgroundTaskManager/AsyncOperation.swift @@ -0,0 +1,76 @@ +import BackgroundTasks + +/// Create another class that conforms to this class to help manage your async operation +/// https://www.avanderlee.com/swift/asynchronous-operations/ +open class AsyncOperation: Operation { + private let lockQueue: DispatchQueue + + public init(label: String) { + self.lockQueue = DispatchQueue(label: label, attributes: .concurrent) + } + + override public var isAsynchronous: Bool { + return true + } + + private var _isExecuting = false + override public private(set) var isExecuting: Bool { + get { + return self.lockQueue.sync { () -> Bool in + return self._isExecuting + } + } + set { + willChangeValue(forKey: "isExecuting") + self.lockQueue.sync(flags: [.barrier]) { + self._isExecuting = newValue + } + didChangeValue(forKey: "isExecuting") + } + } + + private var _isFinished = false + override public private(set) var isFinished: Bool { + get { + return self.lockQueue.sync { () -> Bool in + return self._isFinished + } + } + set { + willChangeValue(forKey: "isFinished") + self.lockQueue.sync(flags: [.barrier]) { + self._isFinished = newValue + } + didChangeValue(forKey: "isFinished") + } + } + + override open func cancel() { + super.cancel() + + self.finish() + } + + override public func start() { + super.start() // calls main() + + guard !self.isCancelled else { + self.finish() + return + } + + self.isFinished = false + self.isExecuting = true + } + + override open func main() { + fatalError("Subclasses must implement `main` without overriding super.") + } + + public func finish() { + guard self.isExecuting else { return } + + self.isExecuting = false + self.isFinished = true + } +} diff --git a/Sources/MSLFoundation/BackgroundTaskManager/BackgroundTaskManager.swift b/Sources/MSLFoundation/BackgroundTaskManager/BackgroundTaskManager.swift new file mode 100644 index 0000000..c369d89 --- /dev/null +++ b/Sources/MSLFoundation/BackgroundTaskManager/BackgroundTaskManager.swift @@ -0,0 +1,155 @@ +import BackgroundTasks +import Combine +import Foundation +import Logging +import UIKit + +private let logger: Logger = { + var logger = Logger(label: "\(#file)") + logger.logLevel = .info + return logger +}() + +public enum EnqueueType { + /// Replace the existing task with the provided one + case replace + + /// Keep the existing task instead of using the provided one + case keep +} + +public final class BackgroundTaskManager { + private let taskIdentifier: String + + private let queue = OperationQueueManager() + + /// The current background refresh task that woke up the application + private var backgroundTask: BGAppRefreshTask? + + /// Create a new BackgroundTaskManager with a unique identifier. This unique identifier will be used when tasks are executed in the background. + public init( + taskId: String + ) { + self.taskIdentifier = taskId + + BGTaskScheduler.shared.register( + forTaskWithIdentifier: self.taskIdentifier, + using: nil + ) { task in + guard let task = task as? BGAppRefreshTask else { return } + self.handleBackgroundTask(task) + } + + // Observe the app entering the foreground + NotificationCenter.default.addObserver( + self, + selector: #selector(self.handleActivate(_:)), + name: UIScene.willEnterForegroundNotification, + object: nil + ) + + // Observe the app entering the background + NotificationCenter.default.addObserver( + self, + selector: #selector(self.handleDeactivate(_:)), + name: UIScene.willDeactivateNotification, + object: nil + ) + + self.queue.addListener(self) + } + + deinit { + self.queue.removeListener(self) + } +} + +// MARK: Public Functions + +public extension BackgroundTaskManager { + /// Adds a new task to the manager. EnqueueType can be used to either `keep` or `replace` a provider that + /// has already been register with the same `identifier`. + func register(type: EnqueueType = .keep, provider: OperationWorkProvider) { + self.queue.register(type: type, provider: provider) + } + + /// Removes a task from the background manager. + func unregister(provider: OperationWorkProvider) { + self.queue.unregister(provider: provider) + } + + /// Begin runing registered tasks. + func start() { + self.queue.start() + } + + /// Prevent the BackgroundTaskManager from running any tasks. + func stop() { + self.queue.stop() + } +} + +// MARK: Helpers + +extension BackgroundTaskManager { + @objc private func handleActivate(_ notification: Notification) { + BGTaskScheduler.shared.cancel( + taskRequestWithIdentifier: self.taskIdentifier + ) + } + + @objc private func handleDeactivate(_ notification: Notification) { + self.scheduleBackgroundTasks() + } + + private func scheduleBackgroundTasks() { + let backgroundTask = BGAppRefreshTaskRequest(identifier: self.taskIdentifier) + backgroundTask.earliestBeginDate = self.queue.nextRunDate + + do { + try BGTaskScheduler.shared.submit(backgroundTask) + + BGTaskScheduler.shared.getPendingTaskRequests { tasks in + let details = tasks.map(\.description).joined(separator: "\n") + logger.debug("\(tasks.count) background tasks scheduled:\n\(details)") + } + } catch { + logger.error("Failed to schedule background tasks!") + logger.error("\(error.localizedDescription)") + } + } + + private func handleBackgroundTask(_ task: BGAppRefreshTask) { + defer { + // Schedule the next background task + self.scheduleBackgroundTasks() + } + + logger.info("App woke up for background refresh task: \(task.description)") + + self.backgroundTask = task + + self.queue.start() + + task.expirationHandler = { + logger.info("Background refresh task expired") + + self.queue.stop() + + self.backgroundTask = nil + } + } +} + +extension BackgroundTaskManager: QueueManagerListener { + func didCompleteQueue() { + logger.info("Background task completed 1 round of work") + } + + func didSleepQueue() { + logger.info("Background work did finish") + + self.backgroundTask?.setTaskCompleted(success: true) + self.backgroundTask = nil + } +} diff --git a/Sources/MSLFoundation/BackgroundTaskManager/OperationQueueManager.swift b/Sources/MSLFoundation/BackgroundTaskManager/OperationQueueManager.swift new file mode 100644 index 0000000..15a02f1 --- /dev/null +++ b/Sources/MSLFoundation/BackgroundTaskManager/OperationQueueManager.swift @@ -0,0 +1,513 @@ +import Combine +import Logging +import Network +import UIKit + +private let logger: Logger = { + var logger = Logger(label: "\(#file)") + logger.logLevel = .info + return logger +}() + +protocol QueueManagerListener: AnyObject { + func didCompleteQueue() + func didSleepQueue() +} + +/// The OperationQueueManager class is responsible for managing a queue of operations, handling their execution, +/// and ensuring thread safety. It provides mechanisms to register operation providers, manage their execution +/// state, and handle background tasks. +/// +/// State Management: The OperationQueueManager maintains the state of the queue with three possible states: +/// stopped, sleeping, and running. +/// +/// Background Task Management: The class also manages background tasks, allowing certain tasks to continue +/// running even when the app is in the background. +final class OperationQueueManager { + public enum State { + case stopped + case sleeping + case running + } + + /// A list of all the currently registered providers + private var providers = [String: OperationWorkProvider]() + + /// A queue used to lock properties being manipulated on multiple threads + /// + /// A really good article on multi-threaded race conditions + /// https://medium.com/swiftcairo/avoiding-race-conditions-in-swift-9ccef0ec0b26 + private let lockQueue = DispatchQueue( + label: "OperationQueueManager", + attributes: .concurrent + ) + + /// Operations of providers that have been enqueued and are currently executing + private var enqueuedProviders = [String: [Operation]]() + + /// Background tasks that have registered and are allowd to run in the background + private var backgroundTasks = [String: UIBackgroundTaskIdentifier]() + + /// Timers used for keeping track of how long a provider has been running + /// and to kill operations if they run for too long + private var providerExpirationTimers = [String: DispatchSourceTimer]() + + /// Maintains the number of times a provider's operations were scheduled to run in the current time allotment. + /// Once all providers have had a chance for their operations to be scheduled, the `runCount` is reset to zero. + private var runCount = [String: Int]() + + /// Maintains the date of the last time a provider was scheduled to do work + private var lastScheduled = [String: Date]() + + /// The queue that operations of providers are run on. + /// This queue supports concurrent execution of operations. + private lazy var operationQueue = OperationQueue() + + /// The next time this queue should run when put to sleep + public var nextRunDate = Date() + + /// Returns the smallest `desiredFrequency` from its `providers` + private var minimumDesiredRunFrequency: TimeInterval? { + // If there are providers that are ready to run _now_, then we return a time of zero. + if self.providers.values.contains(where: { provider in + provider.shouldRepeat == false + }) { + return 0 + } + + // Otherwise, just return the smallest desired run frequency for the registered providers + return self.providers.values.map(\.desiredFrequency).min() + } + + /// A timer that is used to wake up the manager to begin work again + private var sleepTimer: DispatchSourceTimer? + + private var listeners = [QueueManagerListener]() + + public private(set) var state: State = .stopped + + /// Returns `true` if the queue is currently sleeping with no intention of waking up. + /// Queue will stay in deep sleep until a new provider is registered. + private var isDeepSleep: Bool { + return self.state == .sleeping && (self.sleepTimer?.isCancelled ?? true) + } + + private let networkMonitor = NWPathMonitor() + private var networkStatus: NWPath.Status = .requiresConnection + + public init() { + // Observe network connection + self.networkMonitor.pathUpdateHandler = { [weak self] path in + guard let self else { return } + self.networkStatus = path.status + } + self.networkMonitor.start(queue: DispatchQueue(label: "NetworkMonitor")) + } +} + +// MARK: Manipulation Functions + +extension OperationQueueManager { + func register(type: EnqueueType = .keep, provider: OperationWorkProvider) { + let providerAlreadyExists = self.providers.keys.contains(provider.identifier) + + switch type { + case .keep: + if providerAlreadyExists { + // We've choosen to KEEP a work provider that already exists with that identifier + return + } + case .replace: + self.unregister(provider: provider) + } + + self.providers[provider.identifier] = provider + + logger.info("Registered provider: \(String(describing: provider.self))") + + if self.isDeepSleep { + // If we are sleeping with no intention of waking up, wake up now! + self.start() + } else { + // Update the sleep timer in case the new provider has work to do soon + self.updateSleepTimer() + } + } + + func unregister(provider: OperationWorkProvider) { + self.providers[provider.identifier] = nil + self.lastScheduled[provider.identifier] = nil + + logger.info("Unregistered provider: \(String(describing: provider.self))") + } + + func addListener(_ listener: QueueManagerListener) { + guard self.listeners.contains(where: { $0 === listener }) else { return } + self.listeners.append(listener) + } + + func removeListener(_ listener: QueueManagerListener) { + if let index = self.listeners.firstIndex(where: { $0 === listener }) { + self.listeners.remove(at: index) + } + } + + /// Start processing the queue. It is possible that the queue will go to sleep if + /// no work is available to perform. Furthermore, if there are no providers registered, + /// the queue will go into a deep sleep and will not wake up again until another provider is registered. + func start() { + guard self.state != .running else { return } + + self.state = .running + + logger.info("Operation queue has been started") + + self.sleepTimer?.cancel() + + /// Add work to process + self.buildQueue() + } + + /// Stop the queue from continuing to run. + func stop() { + self.state = .stopped + + self.drainOperationQueue() + + logger.info("Operation queue has been stopped") + } +} + +// MARK: Private functions + +extension OperationQueueManager { + // MARK: Sleep Functions + + /// Temporarilly stop the queue. But the queue will wake back up at the earliest frequency of the providers. + private func sleep() { + self.state = .sleeping + + self.drainOperationQueue() + + self.updateSleepTimer() + + for listener in self.listeners { + listener.didSleepQueue() + } + } + + /// Creates a timer to restart the queue operation process + private func updateSleepTimer() { + guard self.state == .sleeping else { return } + + // if we have providers that want to run at a specific frequency, then we should set a timer + // to wake us back up. + if let sleepDuration = self.minimumDesiredRunFrequency { + let proposedNextRunDate = Date(timeIntervalSinceNow: sleepDuration) + let now = Date() + + // Don't update `nextRunDate` if it's going to make the queue sleep longer + if self.nextRunDate > now, proposedNextRunDate > self.nextRunDate { + return + } + + self.nextRunDate = proposedNextRunDate + + logger.trace("Setting sleep timer to wake up at: \(self.nextRunDate.description)") + + self.sleepTimer = self.buildTimer(duration: Int(sleepDuration)) { [weak self] in + logger.trace("Queue waking back up!") + self?.start() + } + } else { + // Go to into deep sleep (i.e. don't wake up until a provider registers) + logger.trace("Queue going into deep sleep.") + self.sleepTimer?.cancel() + } + } + + // MARK: Queue Functions + + /// Find providers with obtainable work within given constraints and add them to the operation queue + private func buildQueue() { + guard self.state == .running else { return } + + // Don't start executing until we finish building the queue + self.operationQueue.isSuspended = true + + self.operationQueue.progress.completedUnitCount = 0 + self.operationQueue.progress.totalUnitCount = 0 + + let providers = self.getProvidersWithObtainableWork() + + // If there is no work to do, stop the queue and setup a sleepTimer + guard providers.isNotEmpty else { + logger.trace("Stopping work because no providers had work to do") + self.sleep() + return + } + + logger.trace("Adding work to the queue...") + + // Build the queue + for provider in providers { + self.addOperationsToQueue(for: provider) + } + + // On completion of queue... + self.operationQueue.addBarrierBlock { + logger.trace("Queue did finish all operations!") + + if !self.operationQueue.progress.isFinished { + let error = "Pogress mismatch! Make sure your Operation calls `super.start()` " + + "to keep accurate progress." + logger.error("\(error)") + } + + // Reset state + self.providerExpirationTimers.removeAll() + self.enqueuedProviders.removeAll() + self.backgroundTasks.removeAll() + + // Notify listeners of queue completion + for listener in self.listeners { + listener.didCompleteQueue() + } + + // Attempt to add more work to the queue + self.buildQueue() + } + + logger.trace("Providers scheduled: \(providers)") + self.operationQueue.isSuspended = false + } + + /// Adds the operation and its dependencies to the queue and allows this + /// work to extend into the background if desired + private func addOperationsToQueue(for provider: OperationWorkProvider) { + let operations = provider.buildWork().compactMap { $0 } + self.enqueuedProviders[provider.identifier] = operations + + for operation in operations { + // Register this operation to run in the background (if desired) + if provider.canRunInBackground { + self.backgroundTasks[provider.identifier] = UIApplication.shared.beginBackgroundTask( + expirationHandler: { + logger.trace("\(operation.description) EXPIRED: \(Date())") + + self.sleep() + } + ) + } + + // The operation that signals work has begun + let startOperation = SimpleOperation( + mainHandler: { + self.lockQueue.sync(flags: [.barrier]) { + guard self.providerExpirationTimers[provider.identifier] == nil else { return } + + logger.trace("Add an expiration timer for provider \(provider.identifier)") + + self.providerExpirationTimers[provider.identifier] = self.buildTimer( + duration: Int(provider.timeoutDuration) + ) { + logger.error("Provider \(provider.identifier) got killed because it didn't finish in time") + for operation in operations { + operation.cancel() + } + } + } + } + ) + + // The operation that signals work has finished + let operationEndWork = { + logger.trace("Completed operation: \(operation.description)") + + // End the background task (if there is one) + if + provider.canRunInBackground, + let id = self.backgroundTasks[provider.identifier] + { + UIApplication.shared.endBackgroundTask(id) + } + + // Clean up tasks + self.lockQueue.sync(flags: [.barrier]) { + guard var enqueuedOperations = self.enqueuedProviders[provider.identifier] else { return } + + // Remove the completed operation from the enqueued list + if let index = enqueuedOperations.firstIndex(of: operation) { + enqueuedOperations.remove(at: index) + } + self.enqueuedProviders[provider.identifier] = enqueuedOperations + + if enqueuedOperations.isEmpty { + logger.debug("Completed work for provider: \(provider.identifier)") + + self.providerExpirationTimers[provider.identifier] = nil + self.enqueuedProviders[provider.identifier] = nil + self.backgroundTasks[provider.identifier] = nil + + // Remove any one-time work providers from the queue manager + if provider.shouldRepeat == false { + self.unregister(provider: provider) + } + } + } + } + + let endOperation = SimpleOperation( + mainHandler: operationEndWork, + cancelHandler: operationEndWork + ) + + // Run startOperation first + // Then operation + // Finally, endOperation + operation.addDependency(startOperation) + endOperation.addDependency(operation) + self.enqueue(endOperation) + } + } + + private func drainOperationQueue() { + self.operationQueue.cancelAllOperations() + logger.trace("Emptied operation queue") + } + + /// Adds the operation and all of its dependencies to the operation queue + private func enqueue(_ operation: Operation) { + // Recursively add dependencies of the given operation to the queue + for dependency in operation.dependencies { + self.enqueue(dependency) + } + + // Add operation to queue + self.operationQueue.progress.totalUnitCount += 1 + self.operationQueue.addOperation(operation) + } +} + +// MARK: Helper Functions + +private extension OperationQueueManager { + /// Returns an array of providers that have work to be done. + /// This function also considers remaining background time and includes providers that can + /// complete their work in the given time constraint. + private func getProvidersWithObtainableWork() -> [OperationWorkProvider] { + var remainingTime = UIApplication.shared.backgroundTimeRemaining + + // If unit testing... + if NSClassFromString("XCTest") != nil { + remainingTime = 30 // seconds + } + + logger.trace("Remaining background time: \(remainingTime)") + + guard remainingTime > 0 else { return [] } + + var results = [OperationWorkProvider]() + + let providers = self.getProvidersWantingToBeScheduled() + let sortedIds = self.getProvidersInPriorityOrder(from: providers) + + // Loop through the providers in priority order + for id in sortedIds { + guard let provider = providers[id] else { continue } + + // Check the `conditions` and verify all have been met + var conditionsMet = true + for condition in provider.conditions { + switch condition { + case let .networkStatus(status): + if self.networkStatus != status { + conditionsMet = false + break + } + case let .minimumBatteryLevel(requiredLevel): + let device = UIDevice.current + let currentBatteryLevel = device.batteryLevel * 100 + if currentBatteryLevel < requiredLevel { + conditionsMet = false + break + } + } + } + + // Verify we have enough time to execute the work provider + let hasTimeToRun = provider.estimatedWorkTime < remainingTime + + // Schedule the provider to run + if conditionsMet, hasTimeToRun { + remainingTime -= provider.estimatedWorkTime + results.append(provider) + + self.lastScheduled[id] = Date() + + // Increment run count for this provider + let count = (self.runCount[id] ?? 0) + 1 + self.runCount[id] = count + } + } + + return results + } + + private func getProvidersWantingToBeScheduled() -> [String: OperationWorkProvider] { + var results = [String: OperationWorkProvider]() + for (id, provider) in self.providers { + if provider.shouldRepeat { + let lastScheduled = self.lastScheduled[id] ?? Date(timeIntervalSince1970: 0) + let desiredRunTime = Date( + timeIntervalSince1970: lastScheduled.timeIntervalSince1970 + provider.desiredFrequency + ) + let now = Date() + + if now >= desiredRunTime { + results[id] = provider + } + } else { + results[id] = provider + } + } + + logger.trace("Providers needing to be scheduled: \(results)") + + return results + } + + /// Gets providers in order of ones that haven't had a chance to run yet + private func getProvidersInPriorityOrder(from providers: [String: OperationWorkProvider]) -> [String] { + // Reset run count if all provider's operations have had a chance to run + if self.runCount.values.min() ?? 0 > 0 { + self.runCount.removeAll() + } + + // Pre-fill run count with default values + for id in providers.keys where self.runCount[id] == nil { + self.runCount[id] = 0 + } + + // Sort providers based on the ones that have run the least + let sortedIds = self.runCount.keys.sorted { lhs, rhs in + return self.runCount[lhs] ?? 0 < self.runCount[rhs] ?? 0 + } + + logger.trace("Providers in priority order: \(sortedIds)") + + return sortedIds + } + + func buildTimer(duration: Int, work: @escaping () -> Void) -> DispatchSourceTimer { + let timer = DispatchSource.makeTimerSource( + flags: .strict, + queue: DispatchQueue.global(qos: .background) + ) + timer.schedule(deadline: DispatchTime.now() + DispatchTimeInterval.seconds(duration)) + timer.setEventHandler(handler: DispatchWorkItem { work() }) + timer.resume() + + return timer + } +} diff --git a/Sources/MSLFoundation/BackgroundTaskManager/OperationWorkProvider.swift b/Sources/MSLFoundation/BackgroundTaskManager/OperationWorkProvider.swift new file mode 100644 index 0000000..e4884b7 --- /dev/null +++ b/Sources/MSLFoundation/BackgroundTaskManager/OperationWorkProvider.swift @@ -0,0 +1,75 @@ +import BackgroundTasks +import Foundation +import Network + +public enum OperationWorkProviderCondition { + case networkStatus(NWPath.Status) + + // Requires UIDevice.current.isBatteryMonitoringEnabled be set to `true` + case minimumBatteryLevel(Float) +} + +/// A protocol that defines the requirements for a provider that supplies work operations. +/// Conforming types are expected to provide information about the work they perform, +/// including estimated execution time, timeout duration, desired frequency, and conditions +/// under which the work should be repeated or allowed to run in the background. +public protocol OperationWorkProvider: AnyObject { + /// A unique identifier for the work provider. + var identifier: String { get } + + /// An estimate on how long the work will take to execute. + var estimatedWorkTime: TimeInterval { get } + + /// If the job does not complete within the specified `timeoutDuration`, then the job will be killed. + /// This helps prevent a job from going on forever. + var timeoutDuration: TimeInterval { get } + + /// Specifies how often this worker would like to be run. + /// The desired requency is only considered if `shouldRepeat` is `true`. + var desiredFrequency: TimeInterval { get } + + /// Indicates whether or not this work should be repeated again in the future + var shouldRepeat: Bool { get } + + /// Indicates if this job is allowed to run when the app is backgrounded + var canRunInBackground: Bool { get } + + /// All conditions must be true in order for this worker to run + var conditions: [OperationWorkProviderCondition] { get } + + /// Returns operations that this provider would like to be added to the queue + /// - Returns: An array of `Operation` objects representing the work to be performed. + func buildWork() -> [Operation] +} + +public extension OperationWorkProvider { + var identifier: String { + return String(describing: type(of: self)) + } + + // Override this to a smaller value if you want your task to potentially run multiple times + // in the background. (Normally the OS only gives us 30 seconds to operate in the background). + var estimatedWorkTime: TimeInterval { + return 5 + } + + // Override this in order to allow your job to run longer, if desired. + var timeoutDuration: TimeInterval { + return 15 + } + + // Defaults to 1 hour + var desiredFrequency: TimeInterval { + return 60 * 60 + } + + // Defaults to allowing operations run in the background + var canRunInBackground: Bool { + return true + } + + // Defaults to no conditions + var conditions: [OperationWorkProviderCondition] { + return [] + } +} diff --git a/Sources/MSLFoundation/BackgroundTaskManager/SimpleOperation.swift b/Sources/MSLFoundation/BackgroundTaskManager/SimpleOperation.swift new file mode 100644 index 0000000..a42cfeb --- /dev/null +++ b/Sources/MSLFoundation/BackgroundTaskManager/SimpleOperation.swift @@ -0,0 +1,26 @@ +import Foundation + +/// A simple subclass of `Operation` that allows for custom main and cancel handlers. +/// This class provides a straightforward way to define the work to be done in an operation +/// and the actions to take if the operation is cancelled. +public final class SimpleOperation: Operation { + private(set) var mainHandler: () -> Void + private(set) var cancelHandler: () -> Void + + public init( + mainHandler: (() -> Void)? = nil, + cancelHandler: (() -> Void)? = nil + ) { + self.mainHandler = mainHandler ?? {} + self.cancelHandler = cancelHandler ?? {} + } + + override public func main() { + self.mainHandler() + } + + override public func cancel() { + super.cancel() + self.cancelHandler() + } +} diff --git a/Sources/MSLFoundation/README.md b/Sources/MSLFoundation/README.md index ca65321..d10ba81 100644 --- a/Sources/MSLFoundation/README.md +++ b/Sources/MSLFoundation/README.md @@ -5,7 +5,8 @@ MSL Foundation provides common helper functions to work at all levels of a proje * [Installation](#installation) ## Features -* [x] [AppMigrator](./documentation/database_manager.md) +* [x] [AppMigrator](./documentation/app_migrator.md) +* [x] [Background Task Manager](./documentation/background_task_manager.md) ## Installation diff --git a/Sources/MSLFoundation/documentation/background_task_manager.md b/Sources/MSLFoundation/documentation/background_task_manager.md new file mode 100644 index 0000000..a90a344 --- /dev/null +++ b/Sources/MSLFoundation/documentation/background_task_manager.md @@ -0,0 +1,72 @@ +# Background Task Manager + +The `BackgroundTaskManager` is a utility designed to manage and execute background tasks efficiently. It ensures that tasks are executed based on specific conditions such as network availability and battery level, and it prioritizes tasks according to their importance. + +## Features + +- **Condition-based Execution**: Tasks can be scheduled to run only when certain conditions are met, such as having an active network connection or a minimum battery level. +- **Priority Management**: Tasks are prioritized and executed based on their importance and urgency. +- **Efficient Resource Management**: Ensures that tasks are executed within the available background time and resources. + +## Usage + +### Initialization + +To use the `BackgroundTaskManager`, you need to initialize it and register your task providers. + +```swift +let backgroundTaskManager = BackgroundTaskManager(taskId: "com.example") +``` + +### Starting the Manager +When you are ready, you can start the `BackgroundTaskManager` to begin executing tasks. You can `register` / `unregister` providers at any time. + +```swift +backgroundTaskManager.start() +``` + +### Registering Task Providers +Task providers are responsible for defining the tasks and their conditions. You can register a task provider with the `BackgroundTaskManager` as follows: + +```swift +let taskProvider = MyTaskProvider() +backgroundTaskManager.register(provider: taskProvider) +``` + +### Stopping the Manager +```swift +backgroundTaskManager.stop() +``` + +### Example +Here is an example of how to use the `BackgroundTaskManager` with a custom task provider: + +```swift +import Foundation + +class MyTaskProvider: OperationWorkProvider { + var conditions: [Condition] { + return [.hasActiveNetwork, .batteryLevel(20)] + } + + var estimatedWorkTime: TimeInterval { + return 60 // 1 minute + } + + func buildWork() -> [Operation] { + return [MyCustomOperation()] + } +} + +let backgroundTaskManager = BackgroundTaskManager(taskId: "com.example") +let taskProvider = MyTaskProvider() + +backgroundTaskManager.register(provider: taskProvider) +backgroundTaskManager.start() +``` + +In this example: + +* `MyTaskProvider` is a custom task provider that specifies the conditions for execution (`hasActiveNetwork` and `batteryLevel(20)`). +* The `buildWork` method returns an array of operations to be executed. +* The `BackgroundTaskManager` is initialized, the task provider is registered, and the manager is started. diff --git a/Tests/MSLFoundationTests/BackgroundTaskManagerTests.swift b/Tests/MSLFoundationTests/BackgroundTaskManagerTests.swift new file mode 100644 index 0000000..5f22483 --- /dev/null +++ b/Tests/MSLFoundationTests/BackgroundTaskManagerTests.swift @@ -0,0 +1,155 @@ +@testable import MSLFoundation +import XCTest + +private var oneTimeJobCounter = 0 +private var repeatingJobCounter = 0 + +final class BackgroundTaskManagerTests: XCTestCase { + private class OneTimeJob: OperationWorkProvider { + let identifier = "OneTimeJob" + let shouldRepeat = false + + func buildWork() -> [Operation] { + return [ + SimpleOperation(mainHandler: { + oneTimeJobCounter += 1 + }), + ] + } + } + + private class RepeatingJob: OperationWorkProvider { + let identifier = "RepeatingJob" + let shouldRepeat = true + let desiredFrequency: TimeInterval = 2 // seconds + + func buildWork() -> [Operation] { + return [ + SimpleOperation(mainHandler: { + repeatingJobCounter += 1 + }), + ] + } + } + + private class FailingAsyncJob: OperationWorkProvider { + let identifier = "FailingAsyncJob" + let shouldRepeat = false + let timeoutDuration: TimeInterval = 2 + + private class FailingOperation: AsyncOperation { + init() { + super.init(label: "FailingOperation") + } + + override func main() { + print("Doesn't call self.finish on purpose") + } + } + + func buildWork() -> [Operation] { + return [ + FailingOperation(), + ] + } + } + + private class SuccessfulAsyncJob: OperationWorkProvider { + let identifier = "SuccessfulAsyncJob" + let shouldRepeat = false + let timeoutDuration: TimeInterval = 3 + + let expectation = XCTestExpectation(description: "Successful Async job finishes") + + private class SuccessOperation: AsyncOperation { + let expectation: XCTestExpectation + + init(expectation: XCTestExpectation) { + self.expectation = expectation + super.init(label: "FailingOperation") + } + + override func main() { + print("Successful Async Job Start") + + DispatchQueue.global(qos: .background).async { + Thread.sleep(forTimeInterval: 2) + self.finish() + + self.expectation.fulfill() + } + } + } + + func buildWork() -> [Operation] { + return [ + SuccessOperation(expectation: self.expectation), + ] + } + } + + func testOperationQueue() { + let manager = OperationQueueManager() + XCTAssert(manager.state == .stopped) + + manager.start() + + // Should not be doing anything because we haven't registered anything yet + XCTAssert(manager.state == .sleeping) + + manager.register(provider: OneTimeJob()) + + // Verify that the manager automatically started running after adding a job + XCTAssert(manager.state == .running) + + Thread.sleep(forTimeInterval: 1) + + // ----------------------------- + // Time: 1 second + + // Verify that the 1 time job ran + XCTAssert(oneTimeJobCounter == 1) + + // Verify that after all jobs have been run and when there are no jobs in the queue, + // the manager goes back to sleeping + XCTAssert(manager.state == .sleeping) + + // Add a repeating job + manager.register(provider: RepeatingJob()) + + // Failing job is strategically placed here to verify that other jobs still get + // run even if one fails in the middle. + manager.register(provider: FailingAsyncJob()) + + let successfulJob = SuccessfulAsyncJob() + manager.register(provider: successfulJob) + + // Verify that the manager is running again + XCTAssert(manager.state == .running) + + Thread.sleep(forTimeInterval: 1) + + // ----------------------------- + // Time: 2 seconds + + // Verify that the repeating job ran once (so far) + XCTAssert(repeatingJobCounter == 1) + + // Verify that the successful async job finishes + wait(for: [successfulJob.expectation], timeout: 10) + + // ----------------------------- + // Time: ~4 seconds + + Thread.sleep(forTimeInterval: 1) + + // ----------------------------- + // Time: ~5 seconds + + // Verify that the repeating job _did_ run again + XCTAssert(repeatingJobCounter > 2) + + // Verify that the one time job did _not_ run again + XCTAssert(oneTimeJobCounter == 1) + } +}