mirror of https://github.com/oxen-io/session-ios
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.
393 lines
15 KiB
Swift
393 lines
15 KiB
Swift
// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import UIKit
|
|
|
|
// MARK: - Singleton
|
|
|
|
public extension Singleton {
|
|
// FIXME: This will be reworked to be part of dependencies in the Groups Rebuild branch
|
|
fileprivate static var _backgroundTaskManager: Atomic<SessionBackgroundTaskManager> = Atomic(SessionBackgroundTaskManager())
|
|
static var backgroundTaskManager: SessionBackgroundTaskManager { _backgroundTaskManager.wrappedValue }
|
|
}
|
|
|
|
// MARK: - SessionBackgroundTaskState
|
|
|
|
public enum SessionBackgroundTaskState {
|
|
case success
|
|
case couldNotStart
|
|
case expired
|
|
case cancelled
|
|
}
|
|
|
|
// MARK: - SessionBackgroundTaskManager
|
|
|
|
public class SessionBackgroundTaskManager {
|
|
/// 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: Timer?
|
|
|
|
/// 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: Timer?
|
|
private var hasGottenValidBackgroundTimeRemaining: Bool = false
|
|
|
|
fileprivate init() {
|
|
self.isAppActive = (
|
|
Singleton.hasAppContext &&
|
|
Singleton.appContext.isMainAppAndActive
|
|
)
|
|
}
|
|
|
|
deinit {
|
|
NotificationCenter.default.removeObserver(self)
|
|
}
|
|
|
|
// MARK: - Functions
|
|
|
|
@discardableResult private static func synced<T>(_ lock: Any, closure: () -> T) -> T {
|
|
objc_sync_enter(lock)
|
|
let result: T = closure()
|
|
objc_sync_exit(lock)
|
|
return result
|
|
}
|
|
|
|
public func startObservingNotifications() {
|
|
guard
|
|
Singleton.hasAppContext &&
|
|
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() {
|
|
SessionBackgroundTaskManager.synced(self) { [weak self] in
|
|
self?.isAppActive = true
|
|
self?.ensureBackgroundTaskState()
|
|
}
|
|
}
|
|
|
|
@objc private func applicationWillResignActive() {
|
|
SessionBackgroundTaskManager.synced(self) { [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 SessionBackgroundTaskManager.synced(self) { [weak self] in
|
|
let taskId: UInt64 = ((self?.idCounter ?? 0) + 1)
|
|
self?.idCounter = taskId
|
|
self?.expirationMap[taskId] = expiration
|
|
|
|
if self?.ensureBackgroundTaskState() != true {
|
|
self?.expirationMap.removeValue(forKey: taskId)
|
|
}
|
|
|
|
self?.continuityTimer?.invalidate()
|
|
self?.continuityTimer = nil
|
|
|
|
// Start observing the background time remaining
|
|
if self?.expirationTimeObserver?.isValid != true {
|
|
self?.hasGottenValidBackgroundTimeRemaining = false
|
|
self?.expirationTimeObserver = Timer.scheduledTimerOnMainThread(
|
|
withTimeInterval: 1,
|
|
repeats: true,
|
|
block: { _ in self?.expirationTimerDidFire() }
|
|
)
|
|
}
|
|
|
|
return taskId
|
|
}
|
|
}
|
|
|
|
fileprivate func removeTask(taskId: UInt64?) {
|
|
guard let taskId: UInt64 = taskId else { return }
|
|
|
|
SessionBackgroundTaskManager.synced(self) { [weak self] 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.
|
|
self?.continuityTimer?.invalidate()
|
|
self?.continuityTimer = Timer.scheduledTimerOnMainThread(
|
|
withTimeInterval: 0.25,
|
|
block: { _ in self?.continuityTimerDidFire() }
|
|
)
|
|
self?.ensureBackgroundTaskState()
|
|
}
|
|
}
|
|
|
|
/// Begins or end a background task if necessary.
|
|
@discardableResult private func ensureBackgroundTaskState() -> Bool {
|
|
// We can't create background tasks in the SAE, but pretend that we succeeded.
|
|
guard
|
|
Singleton.hasAppContext &&
|
|
Singleton.appContext.isMainApp
|
|
else { return true }
|
|
|
|
return SessionBackgroundTaskManager.synced(self) { [weak self] in
|
|
// 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) > 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?.startBackgroundTask() == true)
|
|
}
|
|
|
|
// Need to end background task.
|
|
let maybeBackgroundTaskId: UIBackgroundTaskIdentifier? = self?.backgroundTaskId
|
|
self?.backgroundTaskId = .invalid
|
|
self?.expirationTimeObserver?.invalidate()
|
|
self?.expirationTimeObserver = nil
|
|
|
|
if let backgroundTaskId: UIBackgroundTaskIdentifier = maybeBackgroundTaskId, backgroundTaskId != .invalid {
|
|
Singleton.appContext.endBackgroundTask(backgroundTaskId)
|
|
}
|
|
|
|
return true
|
|
}
|
|
}
|
|
|
|
/// Returns `false` if the background task cannot be begun.
|
|
private func startBackgroundTask() -> Bool {
|
|
guard Singleton.hasAppContext else { return false }
|
|
|
|
return SessionBackgroundTaskManager.synced(self) { [weak self] in
|
|
self?.backgroundTaskId = Singleton.appContext.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)
|
|
self?.backgroundTaskExpired()
|
|
}
|
|
|
|
// If the background task could not begin, return false to indicate that
|
|
return (self?.backgroundTaskId != .invalid)
|
|
}
|
|
}
|
|
|
|
private func backgroundTaskExpired() {
|
|
var backgroundTaskId: UIBackgroundTaskIdentifier = .invalid
|
|
var expirationMap: [UInt64: () -> ()] = [:]
|
|
|
|
SessionBackgroundTaskManager.synced(self) { [weak self] in
|
|
backgroundTaskId = (self?.backgroundTaskId ?? .invalid)
|
|
self?.backgroundTaskId = .invalid
|
|
self?.expirationTimeObserver?.invalidate()
|
|
self?.expirationTimeObserver = nil
|
|
|
|
expirationMap = (self?.expirationMap ?? [:])
|
|
self?.expirationMap.removeAll()
|
|
}
|
|
|
|
/// 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 {
|
|
expirationMap.values.forEach { expirationBlock in
|
|
expirationBlock()
|
|
}
|
|
|
|
/// Apparently we need to "end" even expired background tasks.
|
|
if backgroundTaskId != .invalid {
|
|
Singleton.appContext.endBackgroundTask(backgroundTaskId)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func continuityTimerDidFire() {
|
|
SessionBackgroundTaskManager.synced(self) { [weak self] in
|
|
self?.continuityTimer?.invalidate()
|
|
self?.continuityTimer = nil
|
|
self?.ensureBackgroundTaskState()
|
|
}
|
|
}
|
|
|
|
private func expirationTimerDidFire() {
|
|
guard Singleton.hasAppContext else { return }
|
|
|
|
let backgroundTimeRemaining: TimeInterval = Singleton.appContext.backgroundTimeRemaining
|
|
|
|
SessionBackgroundTaskManager.synced(self) { [weak self] in
|
|
// 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 {
|
|
return
|
|
}
|
|
|
|
self?.hasGottenValidBackgroundTimeRemaining = true
|
|
|
|
// If there is more than 5 seconds remaining then no need to do anything yet (plenty of time to continue running)
|
|
guard backgroundTimeRemaining <= 5 else { return }
|
|
|
|
// There isn't a lot of time remaining so trigger the expiration
|
|
self?.backgroundTaskExpired()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - SessionBackgroundTask
|
|
|
|
public class SessionBackgroundTask {
|
|
/// This property should only be accessed while synchronized on this instance
|
|
private var taskId: UInt64?
|
|
private let label: String
|
|
private var completion: ((SessionBackgroundTaskState) -> ())?
|
|
|
|
// MARK: - Initialization
|
|
|
|
public init(
|
|
label: String,
|
|
completion: @escaping (SessionBackgroundTaskState) -> () = { _ in }
|
|
) {
|
|
self.label = label
|
|
self.completion = completion
|
|
|
|
startBackgroundTask()
|
|
}
|
|
|
|
deinit {
|
|
endBackgroundTask()
|
|
}
|
|
|
|
// MARK: - Functions
|
|
|
|
@discardableResult private static func synced<T>(_ lock: Any, closure: () -> T) -> T {
|
|
objc_sync_enter(lock)
|
|
let result: T = closure()
|
|
objc_sync_exit(lock)
|
|
return result
|
|
}
|
|
|
|
private func startBackgroundTask() {
|
|
// Make a local copy of completion to ensure that it is called exactly once
|
|
var completion: ((SessionBackgroundTaskState) -> ())?
|
|
|
|
self.taskId = Singleton.backgroundTaskManager.addTask { [weak self] in
|
|
Threading.dispatchMainThreadSafe {
|
|
guard let strongSelf = self else { return }
|
|
|
|
SessionBackgroundTask.synced(strongSelf) {
|
|
self?.taskId = nil
|
|
completion = self?.completion
|
|
self?.completion = nil
|
|
}
|
|
|
|
completion?(.expired)
|
|
}
|
|
}
|
|
|
|
// If we didn't get a taskId then the background task could not be started so
|
|
// we should call the completion block with a 'couldNotStart' error
|
|
guard taskId == nil else { return }
|
|
|
|
SessionBackgroundTask.synced(self) { [weak self] in
|
|
completion = self?.completion
|
|
self?.completion = nil
|
|
}
|
|
|
|
if completion != nil {
|
|
Threading.dispatchMainThreadSafe {
|
|
completion?(.couldNotStart)
|
|
}
|
|
}
|
|
}
|
|
|
|
public func cancel() {
|
|
guard taskId != nil else { return }
|
|
|
|
// Make a local copy of completion to ensure that it is called exactly once
|
|
var completion: ((SessionBackgroundTaskState) -> ())?
|
|
|
|
SessionBackgroundTask.synced(self) { [weak self] in
|
|
Singleton.backgroundTaskManager.removeTask(taskId: self?.taskId)
|
|
completion = self?.completion
|
|
self?.taskId = nil
|
|
self?.completion = nil
|
|
}
|
|
|
|
// endBackgroundTask must be called on the main thread.
|
|
if completion != nil {
|
|
Threading.dispatchMainThreadSafe {
|
|
completion?(.cancelled)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func endBackgroundTask() {
|
|
guard taskId != nil else { return }
|
|
|
|
// Make a local copy of completion since this method is called by `dealloc`
|
|
var completion: ((SessionBackgroundTaskState) -> ())?
|
|
|
|
SessionBackgroundTask.synced(self) { [weak self] in
|
|
Singleton.backgroundTaskManager.removeTask(taskId: self?.taskId)
|
|
completion = self?.completion
|
|
self?.taskId = nil
|
|
self?.completion = nil
|
|
}
|
|
|
|
// endBackgroundTask must be called on the main thread.
|
|
if completion != nil {
|
|
Threading.dispatchMainThreadSafe {
|
|
completion?(.cancelled)
|
|
}
|
|
}
|
|
}
|
|
}
|