You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
session-ios/SessionUtilitiesKit/Types/BackgroundTaskManager.swift

348 lines
14 KiB
Swift

// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved.
import UIKit
// MARK: - Singleton
public extension Singleton {
static let backgroundTaskManager: SingletonConfig<SessionBackgroundTaskManager> = Dependencies.create(
identifier: "backgroundTaskManager",
createInstance: { dependencies in SessionBackgroundTaskManager(using: dependencies) }
)
}
// MARK: - SessionBackgroundTaskState
public enum SessionBackgroundTaskState {
case success
case couldNotStart
case expired
case cancelled
}
// MARK: - SessionBackgroundTaskManager
public class SessionBackgroundTaskManager {
/// Maximum duration to extend background tasks
private static let maxBackgroundTime: TimeInterval = 180
private let dependencies: Dependencies
private let queue = DispatchQueue(label: "com.session.backgroundTaskManager")
/// This property should only be accessed while synchronized on this instance.
private var backgroundTaskId: UIBackgroundTaskIdentifier = .invalid
/// This property should only be accessed while synchronized on this instance
var idCounter: UInt64 = 0
/// Note that this flag is set a little early in "will resign active".
///
/// This property should only be accessed while synchronized on this instance.
private var isAppActive: Bool
/// This property should only be accessed while synchronized on this instance.
private var expirationMap: [UInt64: () -> ()] = [:]
/// We use this timer to provide continuity and reduce churn, so that if one SessionBackgroundTask ends right before another
/// begins, we use a single uninterrupted background that spans their lifetimes.
///
/// This property should only be accessed while synchronized on this instance.
private var continuityTimer: DispatchSourceTimer?
/// In order to ensure we have sufficient time to clean up before background tasks expire (without having to kick off additional tasks)
/// we track the remaining background execution time and end tasks 5 seconds early (same as the AppDelegate background fetch)
private var expirationTimeObserver: DispatchSourceTimer?
private var hasGottenValidBackgroundTimeRemaining: Bool = false
fileprivate init(using dependencies: Dependencies) {
self.dependencies = dependencies
self.isAppActive = dependencies[singleton: .appContext].isMainAppAndActive
}
deinit {
NotificationCenter.default.removeObserver(self)
}
// MARK: - Functions
public func startObservingNotifications() {
guard dependencies[singleton: .appContext].isMainApp else { return }
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationDidBecomeActive),
name: .sessionDidBecomeActive,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationWillResignActive),
name: .sessionWillResignActive,
object: nil
)
}
@objc private func applicationDidBecomeActive() {
queue.sync { [weak self] in
self?.isAppActive = true
self?.ensureBackgroundTaskState()
}
}
@objc private func applicationWillResignActive() {
queue.sync { [weak self] in
self?.isAppActive = false
self?.ensureBackgroundTaskState()
}
}
// MARK: - Background Task State Management
// This method registers a new task with this manager. We only bother
// requesting a background task from iOS if the app is inactive (or about
// to become inactive), so this will often not start a background task.
//
// Returns nil if adding this task _should have_ started a
// background task, but the background task couldn't be begun.
// In that case expirationBlock will not be called.
fileprivate func addTask(expiration: @escaping () -> ()) -> UInt64? {
return queue.sync { [weak self] () -> UInt64? in
let taskId: UInt64 = ((self?.idCounter ?? 0) + 1)
self?.idCounter = taskId
self?.expirationMap[taskId] = expiration
if self?.ensureBackgroundTaskState() != true {
self?.expirationMap.removeValue(forKey: taskId)
}
if self?.continuityTimer != nil {
self?.continuityTimer?.cancel()
self?.continuityTimer = nil
}
// Start observing the background time remaining
if self?.expirationTimeObserver?.isCancelled == true {
self?.hasGottenValidBackgroundTimeRemaining = false
self?.checkExpirationTime(in: .seconds(1)) // Don't know the remaining time so check soon
}
return taskId
}
}
fileprivate func removeTask(taskId: UInt64?) {
guard let taskId: UInt64 = taskId else { return }
queue.sync { [weak self, queue] in
self?.expirationMap.removeValue(forKey: taskId)
// This timer will ensure that we keep the background task active (if necessary)
// for an extra fraction of a second to provide continuity between tasks.
// This makes it easier and safer to use background tasks, since most code
// should be able to ensure background tasks by "narrowly" wrapping
// their core logic with a SessionBackgroundTask and not worrying about "hand off"
// between SessionBackgroundTasks.
if self?.continuityTimer != nil {
self?.continuityTimer?.cancel()
self?.continuityTimer = nil
}
self?.continuityTimer = DispatchSource.makeTimerSource(queue: queue)
self?.continuityTimer?.schedule(deadline: .now() + .milliseconds(250))
self?.continuityTimer?.setEventHandler { self?.continuityTimerDidFire() }
self?.continuityTimer?.resume()
self?.ensureBackgroundTaskState()
}
}
/// Begins or end a background task if necessary
///
/// **Note:** Should only be called internally within `queue.sync` for thread safety
@discardableResult private func ensureBackgroundTaskState() -> Bool {
// We can't create background tasks in the SAE, but pretend that we succeeded.
guard dependencies[singleton: .appContext].isMainApp else { return true }
// We only want to have a background task if we are:
// a) "not active" AND
// b1) there is one or more active instance of SessionBackgroundTask OR...
// b2) ...there _was_ an active instance recently.
let shouldHaveBackgroundTask: Bool = (
self.isAppActive == false && (
self.expirationMap.count > 0 ||
self.continuityTimer != nil
)
)
let hasBackgroundTask: Bool = (self.backgroundTaskId != .invalid)
guard shouldHaveBackgroundTask != hasBackgroundTask else {
// Current state is correct
return true
}
guard !shouldHaveBackgroundTask else {
return (self.startOverarchingBackgroundTask() == true)
}
// Need to end background task.
let maybeBackgroundTaskId: UIBackgroundTaskIdentifier? = self.backgroundTaskId
self.backgroundTaskId = .invalid
if self.expirationTimeObserver != nil {
self.expirationTimeObserver?.cancel()
self.expirationTimeObserver = nil
}
if let backgroundTaskId: UIBackgroundTaskIdentifier = maybeBackgroundTaskId, backgroundTaskId != .invalid {
dependencies[singleton: .appContext].endBackgroundTask(backgroundTaskId)
}
return true
}
/// Returns `false` if the background task cannot be begun
///
/// **Note:** Should only be called internally within `queue.sync` for thread safety
private func startOverarchingBackgroundTask() -> Bool {
guard dependencies[singleton: .appContext].isMainApp else { return false }
self.backgroundTaskId = dependencies[singleton: .appContext].beginBackgroundTask { [weak self] in
/// Supposedly `[UIApplication beginBackgroundTaskWithExpirationHandler]`'s handler
/// will always be called on the main thread, but in practice we've observed otherwise.
///
/// See:
/// https://developer.apple.com/documentation/uikit/uiapplication/1623031-beginbackgroundtaskwithexpiratio)
self?.queue.sync {
self?.backgroundTaskExpired()
}
}
// If the background task could not begin, return false to indicate that
return (self.backgroundTaskId != .invalid)
}
/// **Note:** Should only be called internally within `queue.sync` for thread safety
private func backgroundTaskExpired() {
let backgroundTaskId: UIBackgroundTaskIdentifier = self.backgroundTaskId
let expirationMap: [UInt64: () -> ()] = self.expirationMap
self.backgroundTaskId = .invalid
self.expirationMap.removeAll()
if self.expirationTimeObserver != nil {
self.expirationTimeObserver?.cancel()
self.expirationTimeObserver = nil
}
/// Supposedly `[UIApplication beginBackgroundTaskWithExpirationHandler]`'s handler
/// will always be called on the main thread, but in practice we've observed otherwise. SessionBackgroundTask's
/// API guarantees that completionBlock will always be called on the main thread, so we use DispatchSyncMainThreadSafe()
/// to ensure that. We thereby ensure that we don't end the background task until all of the completion blocks have completed.
Threading.dispatchSyncMainThreadSafe { [dependencies] in
expirationMap.values.forEach { expirationBlock in
expirationBlock()
}
/// Apparently we need to "end" even expired background tasks.
if backgroundTaskId != .invalid {
dependencies[singleton: .appContext].endBackgroundTask(backgroundTaskId)
}
}
}
private func checkExpirationTime(in interval: DispatchTimeInterval) {
expirationTimeObserver = DispatchSource.makeTimerSource(queue: queue)
expirationTimeObserver?.schedule(deadline: .now() + interval)
expirationTimeObserver?.setEventHandler { [weak self] in self?.expirationTimerDidFire() }
expirationTimeObserver?.resume()
}
/// Timer will always fire on the `queue` so no need to `queue.sync`
private func continuityTimerDidFire() {
continuityTimer = nil
ensureBackgroundTaskState()
}
/// Timer will always fire on the `queue` so no need to `queue.sync`
private func expirationTimerDidFire() {
expirationTimeObserver = nil
guard dependencies[singleton: .appContext].isMainApp else { return }
let backgroundTimeRemaining: TimeInterval = dependencies[singleton: .appContext].backgroundTimeRemaining
/// It takes the OS a little while to update the 'backgroundTimeRemaining' value so if it hasn't been updated yet then don't do anything
guard self.hasGottenValidBackgroundTimeRemaining == true || backgroundTimeRemaining != .greatestFiniteMagnitude else {
self.checkExpirationTime(in: .seconds(1))
return
}
self.hasGottenValidBackgroundTimeRemaining = true
switch backgroundTimeRemaining {
/// There is more than 10 seconds remaining so no need to do anything yet (plenty of time to continue running)
case 10...: self.checkExpirationTime(in: .seconds(5))
/// There is between 5 and 10 seconds so poll more frequently just in case
case 5..<10: self.checkExpirationTime(in: .milliseconds(2500))
/// There isn't a lot of time remaining so trigger the expiration
default: self.backgroundTaskExpired()
}
}
}
// MARK: - SessionBackgroundTask
public class SessionBackgroundTask {
private let dependencies: Dependencies
private var taskId: UInt64?
private let label: String
private var completion: ((SessionBackgroundTaskState) -> ())?
// MARK: - Initialization
public init(
label: String,
using dependencies: Dependencies,
completion: @escaping (SessionBackgroundTaskState) -> () = { _ in }
) {
self.dependencies = dependencies
self.label = label
self.completion = completion
startBackgroundTask()
}
deinit {
endBackgroundTask()
}
// MARK: - Functions
private func startBackgroundTask() {
taskId = dependencies[singleton: .backgroundTaskManager].addTask { [weak self] in
self?.taskExpired()
}
if taskId == nil {
completion?(.couldNotStart)
completion = nil
}
}
public func cancel() {
guard taskId != nil else { return }
dependencies[singleton: .backgroundTaskManager].removeTask(taskId: taskId)
completion?(.cancelled)
completion = nil
}
private func endBackgroundTask() {
cancel()
}
private func taskExpired() {
completion?(.expired)
completion = nil
}
}