Create and use BackgroundTaskManager.

This commit is contained in:
Brent Simmons
2025-02-01 21:38:43 -08:00
parent aa1ce57f1e
commit d2fb815919
2 changed files with 259 additions and 214 deletions

View File

@@ -7,7 +7,6 @@
//
import UIKit
import BackgroundTasks
import os
import RSCore
import Account
@@ -17,16 +16,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationC
var window: UIWindow?
private var backgroundTaskDispatchQueue = DispatchQueue.init(label: "BGTaskScheduler")
private var waitBackgroundUpdateTask = UIBackgroundTaskIdentifier.invalid
private var isWaitingForSyncTasks = false
private var syncBackgroundUpdateTask = UIBackgroundTaskIdentifier.invalid
private var isSyncArticleStatusRunning = false
private var coordinator: SceneCoordinator?
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Application")
private var unreadCount = 0 {
@@ -61,13 +51,14 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationC
}
if isFirstRun && !AccountManager.shared.anyAccountHasAtLeastOneFeed() {
let localAccount = AccountManager.shared.defaultAccount
DefaultFeedsImporter.importDefaultFeeds(account: localAccount)
DefaultFeedsImporter.importDefaultFeeds(account: AccountManager.shared.defaultAccount)
}
registerBackgroundTasks()
BackgroundTaskManager.shared.delegate = self
BackgroundTaskManager.shared.registerTasks()
CacheCleaner.purgeIfNecessary()
initializeHomeScreenQuickActions()
addHomeScreenQuickActions()
UNUserNotificationCenter.current().requestAuthorization(options: [.badge, .sound, .alert]) { granted, _ in
guard granted else { return }
@@ -77,7 +68,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationC
}
UNUserNotificationCenter.current().delegate = self
_ = UserNotificationManager.shared
_ = ExtensionContainersFile.shared
_ = ExtensionFeedAddRequestFile.shared
@@ -85,10 +76,10 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationC
_ = ArticleStatusSyncTimer.shared
_ = FaviconDownloader.shared
_ = FeedIconDownloader.shared
#if DEBUG
#if DEBUG
ArticleStatusSyncTimer.shared.update()
#endif
#endif
// Create window.
let window = UIWindow(frame: UIScreen.main.bounds)
@@ -119,14 +110,13 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationC
rootSplitViewController.show(.primary)
}
// Update unread count.
self.unreadCount = AccountManager.shared.unreadCount
}
return true
}
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
DispatchQueue.main.async {
AccountManager.shared.resumeAllIfSuspended()
AccountManager.shared.receiveRemoteNotification(userInfo: userInfo) {
@@ -134,60 +124,17 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationC
completionHandler(.newData)
}
}
}
func applicationWillTerminate(_ application: UIApplication) {
ArticleStatusSyncTimer.shared.stop()
}
func applicationDidEnterBackground(_ application: UIApplication) {
IconImageCache.shared.emptyCache()
func applicationWillEnterForeground(_ application: UIApplication) {
prepareAccountsForForeground()
coordinator?.resetFocus()
}
// MARK: - Notifications
private func prepareAccountsForForeground() {
@objc func unreadCountDidChange(_ note: Notification) {
assert(Thread.isMainThread)
assert(note.object is AccountManager)
unreadCount = AccountManager.shared.unreadCount
}
AccountManager.shared.resumeAllIfSuspended()
@objc func accountRefreshDidFinish(_ note: Notification) {
AppDefaults.lastRefresh = Date()
}
@objc func userDidTriggerManualRefresh(_ note: Notification) {
guard let errorHandler = note.userInfo?[UserInfoKey.errorHandler] as? ErrorHandlerBlock else {
assertionFailure("Expected errorHandler in .userDidTriggerManualRefresh userInfo")
return
}
manualRefresh(errorHandler: errorHandler)
}
@objc func userDefaultsDidChange(_ note: Notification) {
updateUserInterfaceStyle()
}
// MARK: - API
func manualRefresh(errorHandler: @escaping ErrorHandlerBlock) {
assert(Thread.isMainThread)
coordinator?.cleanUp(conditional: true)
AccountManager.shared.refreshAll(errorHandler: errorHandler)
}
func prepareAccountsForBackground() {
ExtensionFeedAddRequestFile.shared.suspend()
ArticleStatusSyncTimer.shared.invalidate()
scheduleBackgroundFeedRefresh()
syncArticleStatus()
WidgetDataEncoder.shared.encode()
waitForSyncTasksToFinish()
}
func prepareAccountsForForeground() {
ExtensionFeedAddRequestFile.shared.resume()
ArticleStatusSyncTimer.shared.update()
@@ -202,6 +149,74 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationC
}
}
func applicationDidEnterBackground(_ application: UIApplication) {
IconImageCache.shared.emptyCache()
ArticleStringFormatter.emptyCaches()
prepareAccountsForBackground()
}
private func prepareAccountsForBackground() {
ExtensionFeedAddRequestFile.shared.suspend()
ArticleStatusSyncTimer.shared.invalidate()
BackgroundTaskManager.shared.scheduleBackgroundFeedRefresh()
BackgroundTaskManager.shared.syncArticleStatus()
WidgetDataEncoder.shared.encode()
BackgroundTaskManager.shared.waitForSyncTasksToFinish()
}
func applicationWillTerminate(_ application: UIApplication) {
ArticleStatusSyncTimer.shared.stop()
}
private func suspendApplication() {
guard UIApplication.shared.applicationState == .background else { return }
AccountManager.shared.suspendNetworkAll()
AccountManager.shared.suspendDatabaseAll()
ArticleThemeDownloader.shared.cleanUp()
CoalescingQueue.standard.performCallsImmediately()
coordinator?.suspend()
logger.info("Application processing suspended.")
}
}
// MARK: - Notifications
extension AppDelegate {
@objc func unreadCountDidChange(_ note: Notification) {
assert(Thread.isMainThread)
assert(note.object is AccountManager)
unreadCount = AccountManager.shared.unreadCount
}
@objc func accountRefreshDidFinish(_ note: Notification) {
AppDefaults.lastRefresh = Date()
}
@objc func userDidTriggerManualRefresh(_ note: Notification) {
assert(Thread.isMainThread)
guard let errorHandler = note.userInfo?[UserInfoKey.errorHandler] as? ErrorHandlerBlock else {
assertionFailure("Expected errorHandler in .userDidTriggerManualRefresh userInfo")
return
}
coordinator?.cleanUp(conditional: true)
AccountManager.shared.refreshAll(errorHandler: errorHandler)
}
@objc func userDefaultsDidChange(_ note: Notification) {
updateUserInterfaceStyle()
}
}
// MARK: - UNUserNotificationCenterDelegate
extension AppDelegate {
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
completionHandler([.list, .banner, .badge, .sound])
}
@@ -225,7 +240,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationC
}
}
// MARK: - App Initialization
// MARK: - Home Screen Quick Actions
private extension AppDelegate {
@@ -235,7 +250,7 @@ private extension AppDelegate {
case addFeed = "com.ranchero.NetNewsWire.ShowAdd"
}
private func initializeHomeScreenQuickActions() {
private func addHomeScreenQuickActions() {
let unreadTitle = NSLocalizedString("First Unread", comment: "First Unread")
let unreadIcon = UIApplicationShortcutIcon(systemImageName: "chevron.down.circle")
let unreadItem = UIApplicationShortcutItem(type: ShortcutItemType.firstUnread.rawValue, localizedTitle: unreadTitle, localizedSubtitle: nil, icon: unreadIcon, userInfo: nil)
@@ -271,151 +286,12 @@ private extension AppDelegate {
}
}
// MARK: - Go To Background
// MARK: - BackgroundTaskManagerDelegate
private extension AppDelegate {
extension AppDelegate: BackgroundTaskManagerDelegate {
func waitForSyncTasksToFinish() {
guard !isWaitingForSyncTasks && UIApplication.shared.applicationState == .background else { return }
isWaitingForSyncTasks = true
self.waitBackgroundUpdateTask = UIApplication.shared.beginBackgroundTask { [weak self] in
guard let self = self else { return }
self.completeProcessing(true)
logger.info("Accounts wait for progress terminated for running too long.")
}
DispatchQueue.main.async { [weak self] in
self?.waitToComplete { [weak self] suspend in
self?.completeProcessing(suspend)
}
}
}
func waitToComplete(completion: @escaping (Bool) -> Void) {
guard UIApplication.shared.applicationState == .background else {
logger.info("App came back to foreground, no longer waiting.")
completion(false)
return
}
if AccountManager.shared.refreshInProgress || isSyncArticleStatusRunning || WidgetDataEncoder.shared.isRunning {
logger.info("Waiting for sync to finish…")
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
self?.waitToComplete(completion: completion)
}
} else {
logger.info("Refresh progress complete.")
completion(true)
}
}
func completeProcessing(_ suspend: Bool) {
if suspend {
suspendApplication()
}
UIApplication.shared.endBackgroundTask(self.waitBackgroundUpdateTask)
self.waitBackgroundUpdateTask = UIBackgroundTaskIdentifier.invalid
isWaitingForSyncTasks = false
}
func syncArticleStatus() {
guard !isSyncArticleStatusRunning else { return }
isSyncArticleStatusRunning = true
let completeProcessing = { [unowned self] in
self.isSyncArticleStatusRunning = false
UIApplication.shared.endBackgroundTask(self.syncBackgroundUpdateTask)
self.syncBackgroundUpdateTask = UIBackgroundTaskIdentifier.invalid
}
self.syncBackgroundUpdateTask = UIApplication.shared.beginBackgroundTask {
completeProcessing()
self.logger.info("Accounts sync processing terminated for running too long.")
}
DispatchQueue.main.async {
AccountManager.shared.syncArticleStatusAll {
completeProcessing()
}
}
}
func suspendApplication() {
guard UIApplication.shared.applicationState == .background else { return }
AccountManager.shared.suspendNetworkAll()
AccountManager.shared.suspendDatabaseAll()
ArticleThemeDownloader.shared.cleanUp()
CoalescingQueue.standard.performCallsImmediately()
coordinator?.suspend()
logger.info("Application processing suspended.")
}
}
// MARK: - Background Tasks
private extension AppDelegate {
static let refreshTaskIdentifier = "com.ranchero.NetNewsWire.FeedRefresh"
/// Register all background tasks.
func registerBackgroundTasks() {
// Register background feed refresh.
BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.refreshTaskIdentifier, using: nil) { (task) in
self.performBackgroundFeedRefresh(with: task as! BGAppRefreshTask)
}
}
/// Schedules a background app refresh based on `AppDefaults.refreshInterval`.
func scheduleBackgroundFeedRefresh() {
let request = BGAppRefreshTaskRequest(identifier: Self.refreshTaskIdentifier)
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
// We send this to a dedicated serial queue because as of 11/05/19 on iOS 13.2 the call to the
// task scheduler can hang indefinitely.
backgroundTaskDispatchQueue.async {
do {
try BGTaskScheduler.shared.submit(request)
} catch {
self.logger.error("Could not schedule app refresh: \(error.localizedDescription)")
}
}
}
/// Performs background feed refresh.
/// - Parameter task: `BGAppRefreshTask`
/// - Warning: As of Xcode 11 beta 2, when triggered from the debugger this doesn't work.
func performBackgroundFeedRefresh(with task: BGAppRefreshTask) {
scheduleBackgroundFeedRefresh() // schedule next refresh
logger.info("Woken to perform account refresh.")
DispatchQueue.main.async {
if AccountManager.shared.isSuspended {
AccountManager.shared.resumeAll()
}
AccountManager.shared.refreshAll(errorHandler: ErrorHandler.log) { [unowned self] in
if !AccountManager.shared.isSuspended {
self.suspendApplication()
logger.info("Account refresh operation completed.")
task.setTaskCompleted(success: true)
}
}
}
// set expiration handler
task.expirationHandler = { [weak task] in
self.logger.info("Accounts refresh processing terminated for running too long.")
DispatchQueue.main.async {
self.suspendApplication()
task?.setTaskCompleted(success: false)
}
}
func backgroundTaskManagerApplicationShouldSuspend(_: BackgroundTaskManager) {
suspendApplication()
}
}
@@ -441,7 +317,7 @@ private extension AppDelegate {
return
}
account!.markArticles(article!, statusKey: .read, flag: true) { _ in }
self.prepareAccountsForBackground()
prepareAccountsForBackground()
account!.syncArticleStatus(completion: { [weak self] _ in
if !AccountManager.shared.isSuspended {
self?.prepareAccountsForBackground()

View File

@@ -0,0 +1,169 @@
//
// BackgroundTaskManager.swift
// NetNewsWire-iOS
//
// Created by Brent Simmons on 2/1/25.
// Copyright © 2025 Ranchero Software. All rights reserved.
//
import Foundation
import UIKit
import BackgroundTasks
import os
import Account
protocol BackgroundTaskManagerDelegate: AnyObject {
/// Called when application should suspend networking, database, and other processing.
func backgroundTaskManagerApplicationShouldSuspend(_: BackgroundTaskManager)
}
/// Registers and runs background tasks using the iOS BackgroundTasks API.
final class BackgroundTaskManager {
static let shared = BackgroundTaskManager()
weak var delegate: BackgroundTaskManagerDelegate?
private var backgroundTaskDispatchQueue = DispatchQueue.init(label: "BGTaskScheduler")
private var waitBackgroundUpdateTask = UIBackgroundTaskIdentifier.invalid
private var isWaitingForSyncTasks = false
private var syncBackgroundUpdateTask = UIBackgroundTaskIdentifier.invalid
private var isSyncArticleStatusRunning = false
static let refreshTaskIdentifier = "com.ranchero.NetNewsWire.FeedRefresh"
private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "BackgroundTasks")
/// Register background feed refresh.
func registerTasks() {
BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.refreshTaskIdentifier, using: nil) { task in
self.performBackgroundFeedRefresh(with: task as! BGAppRefreshTask)
}
}
/// Schedules a background app refresh based on `AppDefaults.refreshInterval`.
func scheduleBackgroundFeedRefresh() {
let request = BGAppRefreshTaskRequest(identifier: Self.refreshTaskIdentifier)
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
// We send this to a dedicated serial queue because as of 11/05/19 on iOS 13.2 the call to the
// task scheduler can hang indefinitely.
backgroundTaskDispatchQueue.async {
do {
try BGTaskScheduler.shared.submit(request)
} catch {
Self.logger.error("Could not schedule app refresh: \(error.localizedDescription)")
}
}
}
func waitForSyncTasksToFinish() {
guard !isWaitingForSyncTasks && UIApplication.shared.applicationState == .background else { return }
isWaitingForSyncTasks = true
waitBackgroundUpdateTask = UIApplication.shared.beginBackgroundTask {
self.completeProcessing(true)
Self.logger.info("Accounts wait for progress terminated for running too long.")
}
DispatchQueue.main.async {
self.waitToComplete { suspend in
self.completeProcessing(suspend)
}
}
}
func syncArticleStatus() {
guard !isSyncArticleStatusRunning else { return }
isSyncArticleStatusRunning = true
let completeProcessing = { [unowned self] in
self.isSyncArticleStatusRunning = false
UIApplication.shared.endBackgroundTask(self.syncBackgroundUpdateTask)
self.syncBackgroundUpdateTask = UIBackgroundTaskIdentifier.invalid
}
self.syncBackgroundUpdateTask = UIApplication.shared.beginBackgroundTask {
completeProcessing()
Self.logger.info("Accounts sync processing terminated for running too long.")
}
DispatchQueue.main.async {
AccountManager.shared.syncArticleStatusAll {
completeProcessing()
}
}
}
}
private extension BackgroundTaskManager {
/// Performs background feed refresh.
/// - Parameter task: `BGAppRefreshTask`
/// - Warning: As of Xcode 11 beta 2, when triggered from the debugger this doesn't work.
func performBackgroundFeedRefresh(with task: BGAppRefreshTask) {
scheduleBackgroundFeedRefresh() // schedule next refresh
Self.logger.info("Woken to perform account refresh.")
DispatchQueue.main.async {
if AccountManager.shared.isSuspended {
AccountManager.shared.resumeAll()
}
AccountManager.shared.refreshAll(errorHandler: ErrorHandler.log) {
if !AccountManager.shared.isSuspended {
self.suspendApplication()
Self.logger.info("Account refresh operation completed.")
task.setTaskCompleted(success: true)
}
}
}
// set expiration handler
task.expirationHandler = { [weak task] in
Self.logger.info("Accounts refresh processing terminated for running too long.")
DispatchQueue.main.async {
self.suspendApplication()
task?.setTaskCompleted(success: false)
}
}
}
func waitToComplete(completion: @escaping (Bool) -> Void) {
guard UIApplication.shared.applicationState == .background else {
Self.logger.info("App came back to foreground, no longer waiting.")
completion(false)
return
}
if AccountManager.shared.refreshInProgress || isSyncArticleStatusRunning || WidgetDataEncoder.shared.isRunning {
Self.logger.info("Waiting for sync to finish…")
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
self.waitToComplete(completion: completion)
}
} else {
Self.logger.info("Refresh progress complete.")
completion(true)
}
}
func completeProcessing(_ suspend: Bool) {
if suspend {
suspendApplication()
}
UIApplication.shared.endBackgroundTask(self.waitBackgroundUpdateTask)
waitBackgroundUpdateTask = UIBackgroundTaskIdentifier.invalid
isWaitingForSyncTasks = false
}
func suspendApplication() {
assert(delegate != nil)
assert(Thread.isMainThread)
delegate?.backgroundTaskManagerApplicationShouldSuspend(self)
}
}