Pierre Janineh
Pierre Janineh

Reputation: 702

Registered BGTaskScheduler task isn't running launchHandler

I'm trying to schedule a background task to run in the background using BGTaskScheduler.shared.register(identifier:queue:launchHandler:), but the launchHandler block is never executed.

What I've tried:

Here's my code:

func scene(_ scene: UIScene,
           willConnectTo session: UISceneSession,
           options connectionOptions: UIScene.ConnectionOptions) {        
    let isRegistered = BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.example.bgTaskIdentifier",
                                                       using: nil) { task in
        print("Executed") // Does not print
        self.handleAppRefresh(task: task as! BGAppRefreshTask)
    }
    
    print("isRegistered: \(isRegistered)") // Prints "isRegistered: true"
}


func sceneDidEnterBackground(_ scene: UIScene) {
    scheduleAppRefresh()
    print("Entered Background") // Prints "Entered Background"
}

func scheduleAppRefresh() {
    let request = BGAppRefreshTaskRequest(identifier: "com.example.bgTaskIdentifier")
    request.earliestBeginDate = Date(timeIntervalSinceNow: 5 * 60)
    
    do {
        try BGTaskScheduler.shared.submit(request)
    } catch {
        print("Could not schedule app refresh: \(error)") // Does not reach the catch block
    }
}

Upvotes: 3

Views: 1167

Answers (1)

Rob
Rob

Reputation: 437542

Two observations:

  1. On my Simulator, when I reach the BGTaskScheduler.shared.submit(request) line, it throws BGTaskScheduler.Error.invalid error, whose documentation informs us that this can happen if:

    The app is running on Simulator which doesn’t support background processing.

  2. When I launch the app on an actual device from the Xcode debugger, I leave the app to have it go into background and I see scheduleAppRefresh is called). At that point I tap the Xcode debugger “Pause program execution” button, and then at the (lldb) prompt I enter:

    e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.example.bgTaskIdentifier"]
    

    I then tap on the “Continue program execution” button, and my handleAppRefresh method is called.

    enter image description here

    This debugging process is outlined in Starting and Terminating Tasks During Development.

  3. You are registering the BGAppRefreshTask task identifier in the SceneDelegate. You really want to do that in the AppDelegate. As the register documentation says:

    Registration of all launch handlers must be complete before the end of applicationDidFinishLaunching(_:).


There are three additional things I would check:

  1. Go to the “Settings” app on your device, and pull up the settings for your app, and make sure that “Background App Refresh” is turned on.

  2. Take a look at your handleAppRefresh(task:) implementation and make sure all paths of execution call setTaskCompleted(success:). As the docs say:

    Not calling setTaskCompleted(success:) before the time for the task expires may result in the system killing your app.

    Note, if this happens, you may not be eligible for future fetch requests. I have not empirically confirmed this, though this is common in iOS background execution patterns.

  3. Make sure your app refresh task actually performs a network operation. If you do not, the OS can detect that and may opt the app out of future background app refreshes. (The idea is that they are trying to detect/prevent misuse of the background fetch capability.)


For what it is worth, here is an example of my background task manager object:

//  BackgroundTaskManager.swift
//
//  Created by Robert Ryan on 5/14/24.

import Foundation
import os.log
import BackgroundTasks
import UserNotifications

@MainActor
class BackgroundTaskManager {
    static let shared = BackgroundTaskManager()
    
    private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "BackgroundTaskManager")
    private let appRefreshIdentifier = "com.example.bgTaskIdentifier"

    private init() { }

    @discardableResult
    func register() -> Bool {
        let isRegistered = BGTaskScheduler.shared.register(
            forTaskWithIdentifier: appRefreshIdentifier,
            using: nil
        ) { [weak self, logger] task in
            guard let self else { return }
            logger.notice("\(#function, privacy: .public): register closure called")
            Task { [task] in
                let processTask = Task { await self.handleAppRefresh() }
                task.expirationHandler = { processTask.cancel() }
                task.setTaskCompleted(success: await processTask.value)
            }
        }

        logger.notice("\(#function, privacy: .public): isRegistered = \(isRegistered)")

        return isRegistered
    }

    func scheduleAppRefresh() {
        logger.notice(#function)

        let request = BGAppRefreshTaskRequest(identifier: appRefreshIdentifier)
        request.earliestBeginDate = Date(timeIntervalSinceNow: 5 * 60)

        do {
            try BGTaskScheduler.shared.submit(request)
        } catch {
            logger.error("\(#function, privacy: .public): Could not schedule app refresh: \(error)")
        }
    }
}

// MARK: - Private methods

private extension BackgroundTaskManager {
    func handleAppRefresh() async -> Bool {
        logger.notice("\(#function, privacy: .public): starting")

        // make sure to schedule again, so that this continues to enjoy future background fetch

        scheduleAppRefresh()

        // now fetch data
        
        do {
            try await DataRepositoryService.shared.fetchNewData()
            notifyUserInDebugBuild(message: "\(#function): success")
            logger.notice("\(#function, privacy: .public): success")
            return true
        } catch {
            notifyUserInDebugBuild(message: "\(#function): failed")
            logger.error("\(#function, privacy: .public): failed \(error, privacy: .public)")
            return false
        }
    }

    func notifyUserInDebugBuild(message: String) {
#if DEBUG
        logger.notice("\(#function, privacy: .public): message: \(message, privacy: .public)")

        let content = UNMutableNotificationContent()
        content.title = "BackgroundTaskManager"
        content.body = message
        let request = UNNotificationRequest(identifier: Bundle.main.bundleIdentifier!, content: content, trigger: nil)

        UNUserNotificationCenter.current().add(request)
#endif
    }
}

I then register in the AppDelegate’s didFinishLaunchingWithOptions:

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "AppDelegate")

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        logger.notice(#function)

        BackgroundTaskManager.shared.register()
        BackgroundTaskManager.shared.scheduleAppRefresh()

        return true
    }

    …
}

While I schedule in didFinishLaunchingWithOptions, I also register in the SceneDelegate’s sceneDidEnterBackground:

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "SceneDelegate")
    var window: UIWindow?

    …

    func sceneDidEnterBackground(_ scene: UIScene) {
        logger.notice(#function)
        BackgroundTaskManager.shared.scheduleAppRefresh()
    }
}

A few debugging observations:

  • I use Logger to record messages to the console, rather than print statements. That way, I can not only see it in the Xcode console, but even I run the app independently of Xcode, I can watch the messages in the macOS Console.

  • I can even download these Logger messages stored on my device, after the fact:

    sudo log collect --device --start '2024-05-13 10:00:00' --output background-fetch.logarchive
    open background-fetch.logarchive
    

    With the .logarchive extension, it will open this in the macOS Console app (and you can then filter by subsystem, category, time range, etc.). For more information, see WWDC 2020 video Explore logging in Swift

    enter image description here

    As an aside, this is why I used Logger’s notice, rather than debug. The debug messages are not persisted, while the notice messages are.

  • Another good diagnostic tool is to use “user notifications” (which I implement only in debug builds). This way, even when the user has left the app, when the background fetch is completed, there is a notification on the device, so I have external verification that the background fetch was performed.

    Note, just remember to ask for authorization to present notifications:

    import UIKit
    import UserNotifications
    import os.log
    
    class ViewController: UIViewController {
        let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ViewController")
    
        override func viewDidAppear(_ animated: Bool) {
            super.viewDidAppear(animated)
    
            UNUserNotificationCenter.current().requestAuthorization(options: .alert) { [logger] granted, error in
                logger.notice("\(granted), \(String(describing: error))")
            }
        }
    }
    

    But when the app is launched in the background, I will see a notification on my device:

    enter image description here

Upvotes: 3

Related Questions