TM-SGNL-iOS/SignalServiceKit/Util/OWSBackgroundTask.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

393 lines
14 KiB
Swift

//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
public enum BackgroundTaskState {
case success
case couldNotStart
case expired
}
/// This class makes it easier and safer to use background tasks.
///
/// * Uses RAII (Resource Acquisition Is Initialization) pattern.
/// * Ensures completion block is called exactly once and on main thread,
/// to facilitate handling "background task timed out" case, for example.
/// * Ensures we properly handle the "background task could not be created"
/// case.
///
/// Usage:
///
/// * Use init to start a background task.
/// * Retain a strong reference to the OWSBackgroundTask during the "work".
/// * Clear all references to the OWSBackgroundTask when the work is done,
/// if possible.
@objc
public class OWSBackgroundTask: NSObject {
private let label: String
// TODO: Replace all of the below ivars with Mutex in Swift 6.
private let lock = NSRecursiveLock()
/// This property should only be accessed while holding `lock`.
private var taskId: UInt64?
/// This property should only be accessed while holding `lock`.
private var completionBlock: (@MainActor @Sendable (BackgroundTaskState) -> Void)?
// This could be a default param but objc is in the way for now.
@objc
public convenience init(label: String) {
self.init(label: label, completionBlock: nil)
}
/// - Parameters:
/// - completionBlock: will be called exactly once on the main thread
public init(label: String, completionBlock: (@MainActor @Sendable (BackgroundTaskState) -> Void)?) {
owsAssertDebug(!label.isEmpty)
self.label = label
self.completionBlock = completionBlock
super.init()
start()
}
deinit {
end()
}
private func start() {
taskId = OWSBackgroundTaskManager.shared.addTaskWithExpirationBlock { [weak self] in
DispatchMainThreadSafe {
guard let self else {
return
}
// Make a local copy of completionBlock to ensure that it is called
// exactly once.
var completionBlock: (@MainActor @Sendable (BackgroundTaskState) -> Void)?
self.lock.withLock {
guard self.taskId != nil else {
return
}
Logger.info("\(self.label) background task expired.")
self.taskId = nil
completionBlock = self.completionBlock
self.completionBlock = nil
}
if let completionBlock {
completionBlock(.expired)
}
}
}
// If a background task could not be begun, call the completion block.
if taskId == nil {
// Make a local copy of completionBlock to ensure that it is called
// exactly once.
var completionBlock: (@MainActor @Sendable (BackgroundTaskState) -> Void)?
lock.withLock {
completionBlock = self.completionBlock
self.completionBlock = nil
}
if let completionBlock {
DispatchMainThreadSafe {
completionBlock(.couldNotStart)
}
}
}
}
public func end() {
// Make a local copy of this state, since this method is called by `dealloc`.
var completionBlock: (@MainActor @Sendable (BackgroundTaskState) -> Void)?
lock.withLock {
guard let taskId = self.taskId else {
return
}
OWSBackgroundTaskManager.shared.removeTask(taskId)
self.taskId = nil
completionBlock = self.completionBlock
self.completionBlock = nil
}
// endBackgroundTask must be called on the main thread.
DispatchMainThreadSafe {
if let completionBlock {
completionBlock(.success)
}
}
}
}
public class OWSBackgroundTaskManager {
public static let shared = {
if Thread.isMainThread {
return OWSBackgroundTaskManager()
} else {
return DispatchQueue.main.sync {
OWSBackgroundTaskManager()
}
}
}()
/// We use this timer to provide continuity and reduce churn,
/// so that if one OWSBackgroundTask ends right before another
/// begins, we use a single uninterrupted background task that
/// spans their lifetimes.
///
/// This property should only be accessed on the main thread.
private var continuityTimer: Timer?
// TODO: Replace all of the below ivars with Mutex in Swift 6.
private let lock = NSRecursiveLock()
/// This property should only be accessed while holding `lock`.
private var backgroundTaskId: UIBackgroundTaskIdentifier = .invalid
/// This property should only be accessed while holding `lock`.
private var expirationMap: [UInt64: @Sendable () -> Void] = [:]
/// This property should only be accessed while holding `lock`.
private var idCounter: UInt64 = 0
/// Note that this flag is set a little early in "will resign active".
///
/// This property should only be accessed while holding `lock`.
private var isAppActive: Bool = CurrentAppContext().isMainAppAndActive
/// This property should only be accessed while holding `lock`.
private var isMaintainingContinuity: Bool = false
/// This property should only be accessed while holding `lock`.
private var didBecomeActiveObserver: (any NSObjectProtocol)?
/// This property should only be accessed while holding `lock`.
private var willResignActiveObserver: (any NSObjectProtocol)?
/// Due to `isAppActive` should only be executed from the main thread.
private init() {
AssertIsOnMainThread()
SwiftSingletons.register(self)
}
deinit {
lock.withLock {
self.clearObservers()
}
}
/// Do not call unless `lock` is held.
private func clearObservers() {
if let didBecomeActiveObserver = self.didBecomeActiveObserver {
NotificationCenter.default.removeObserver(didBecomeActiveObserver, name: .OWSApplicationDidBecomeActive, object: nil)
self.didBecomeActiveObserver = nil
}
if let willResignActiveObserver = self.willResignActiveObserver {
NotificationCenter.default.removeObserver(willResignActiveObserver, name: .OWSApplicationWillResignActive, object: nil)
self.willResignActiveObserver = nil
}
}
public func observeNotifications() {
guard CurrentAppContext().isMainApp else {
return
}
lock.withLock {
self.clearObservers()
self.didBecomeActiveObserver = NotificationCenter.default.addObserver(forName: .OWSApplicationDidBecomeActive, object: nil, queue: nil) { [weak self] _ in
AssertIsOnMainThread()
guard let self else {
return
}
self.lock.withLock {
self.isAppActive = true
self.ensureBackgroundTaskState()
}
}
self.willResignActiveObserver = NotificationCenter.default.addObserver(forName: .OWSApplicationWillResignActive, object: nil, queue: nil) { [weak self] _ in
AssertIsOnMainThread()
guard let self else {
return
}
self.lock.withLock {
self.isAppActive = false
self.ensureBackgroundTaskState()
}
}
}
}
/// 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 addTaskWithExpirationBlock(_ expirationBlock: @escaping @Sendable () -> Void) -> UInt64? {
lock.withLock {
self.idCounter += 1
let taskId = self.idCounter
self.expirationMap[taskId] = expirationBlock
guard self.ensureBackgroundTaskState() else {
self.expirationMap.removeValue(forKey: taskId)
return nil
}
return taskId
}
}
fileprivate func removeTask(_ taskId: UInt64) {
var shouldMaintainContinuity = false
lock.withLock {
let removedBlock = self.expirationMap.removeValue(forKey: taskId)
owsAssertDebug(removedBlock != nil)
// If expirationMap has just been emptied, try to maintain continuity.
// See: scheduleCleanupOfContinuity().
shouldMaintainContinuity = self.expirationMap.isEmpty
if shouldMaintainContinuity {
self.isMaintainingContinuity = true
}
self.ensureBackgroundTaskState()
}
if shouldMaintainContinuity {
self.scheduleCleanupOfContinuity()
}
}
/// Begins or end a background task if necessary.
@discardableResult
private func ensureBackgroundTaskState() -> Bool {
guard CurrentAppContext().isMainApp else {
// We can't create background tasks in the SAE, but pretend that we succeeded.
return true
}
return lock.withLock {
// We only want to have a background task if we are:
// a) "not active" AND
// b1) there is one or more active instance of OWSBackgroundTask OR...
// b2) ...there _was_ an active instance recently.
let shouldHaveBackgroundTask = (!self.isAppActive && (!self.expirationMap.isEmpty || self.isMaintainingContinuity))
let hasBackgroundTask = self.backgroundTaskId != .invalid
if shouldHaveBackgroundTask == hasBackgroundTask {
// Current state is correct.
return true
} else if shouldHaveBackgroundTask {
Logger.info("Starting background task.")
return self.startBackgroundTask()
} else {
// Need to end background task.
Logger.info("Ending background task.")
CurrentAppContext().endBackgroundTask(self.backgroundTaskId)
self.backgroundTaskId = .invalid
return true
}
}
}
/// - Returns false if the background task cannot be begun.
private func startBackgroundTask() -> Bool {
owsAssertDebug(CurrentAppContext().isMainApp)
return lock.withLock {
owsAssertDebug(self.backgroundTaskId == .invalid)
self.backgroundTaskId = CurrentAppContext().beginBackgroundTask {
// 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)
AssertIsOnMainThread()
self.backgroundTaskExpired()
}
// If the background task could not begin, return NO to indicate that.
guard self.backgroundTaskId != .invalid else {
Logger.warn("background task could not be started.")
return false
}
return true
}
}
private func backgroundTaskExpired() {
let (backgroundTaskId, expirationMap) = lock.withLock {
let backgroundTaskId = self.backgroundTaskId
self.backgroundTaskId = .invalid
let expirationMap = self.expirationMap
self.expirationMap.removeAll()
return (backgroundTaskId, expirationMap)
}
// Supposedly [UIApplication beginBackgroundTaskWithExpirationHandler]'s handler
// will always be called on the main thread, but in practice we've observed
// otherwise. OWSBackgroundTask'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.
DispatchSyncMainThreadSafe {
for expirationBlock in expirationMap.values {
expirationBlock()
}
if backgroundTaskId != .invalid {
// Apparently we need to "end" even expired background tasks.
CurrentAppContext().endBackgroundTask(backgroundTaskId)
}
}
}
private func scheduleCleanupOfContinuity() {
// 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 OWSBackgroundTask and not worrying about "hand off"
// between OWSBackgroundTasks.
DispatchQueue.main.async {
self.continuityTimer?.invalidate()
self.continuityTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: false, block: { [weak self] timer in
AssertIsOnMainThread()
guard let self else {
timer.invalidate()
return
}
self.continuityTimer?.invalidate()
self.continuityTimer = nil
self.lock.withLock {
self.isMaintainingContinuity = false
self.ensureBackgroundTaskState()
}
})
}
}
}