iosunusernotificationcenter

How can userNotificationCenter(_:didReceive:) cause a crash even with nothing in the body of the function?


I have an app with notifications that have notification actions. Everything has worked well for years using a class that conforms to UNUserNotificationCenterDelegate and handling the actions in its userNotificationCenter(_:didReceive:withCompletionHandler) method.

Recently, I refactored a lot of my app’s code to use async/await. As a result, I switched to the async version of the delegate method, userNotificationCenter(_:didReceive:) async, which no longer uses the completion handler.

After releasing an update that used the async version of the method, I started seeing a ton of crashes on my device and out in the wild. Here’s an example, with the app name removed:

Exception Type:  EXC_CRASH (SIGABRT)
Exception Codes: 0x0000000000000000, 0x0000000000000000
Triggered by Thread:  16

Application Specific Information:
abort() called


Last Exception Backtrace:
0   CoreFoundation                         0x197ba2248 __exceptionPreprocess + 164
1   libobjc.A.dylib                        0x190f63a68 objc_exception_throw + 60
2   Foundation                             0x19252281c _userInfoForFileAndLine + 0
3   UIKitCore                              0x19aa4fe94 -[UIApplication _performBlockAfterCATransactionCommitSynchronizes:] + 404
4   UIKitCore                              0x19aa5c20c -[UIApplication _updateStateRestorationArchiveForBackgroundEvent:saveState:exitIfCouldNotRestoreState:updateSnapshot:windowScene:] + 528
5   UIKitCore                              0x19aa5c4d0 -[UIApplication _updateSnapshotAndStateRestorationWithAction:windowScene:] + 144
6                                          0x1006c36a8 @objc closure #1 in NotificationDelegate.userNotificationCenter(_:didReceive:) + 132
7                                          0x1006c37d1 partial apply for @objc closure #1 in NotificationDelegate.userNotificationCenter(_:didReceive:) + 1
8                                          0x10049d845 thunk for @escaping @callee_guaranteed @Sendable @async () -> () + 1
9                                          0x1005ceb3d thunk for @escaping @callee_guaranteed @Sendable @async () -> ()partial apply + 1
10                                         0x1005cea01 specialized thunk for @escaping @callee_guaranteed @Sendable @async () -> (@out A) + 1
11                                         0x1005cec75 partial apply for specialized thunk for @escaping @callee_guaranteed @Sendable @async () -> (@out A) + 1
12  libswift_Concurrency.dylib             0x1a1dce2d1 completeTaskWithClosure(swift::AsyncContext*, swift::SwiftError*) + 1

Based on testing, the crash happens when taking action on a notification, like tapping the notification to open the app, for example (responding to UNNotificationDefaultActionIdentifier).

From the delegate method, I pass the center and response to a method that handles the processing asynchronously. In an attempt to narrow down the issue, I eliminated one piece at a time out of my processResponse(_:didReceive:) async method, until I was left with nothing but a print statement:

func userNotificationCenter(
    _ center: UNUserNotificationCenter,
    didReceive response: UNNotificationResponse)
async
{
    await processResponse(center,
                          response: response)
}

private func processResponse(_ center: UNUserNotificationCenter,
                             response: UNNotificationResponse)
async
{
    print("Do almost nothing...")
}

Even this minimal example results crashes. How can I troubleshoot this further to get the userNotificationCenter(_:didReceive:) async method working without these crashes?

The way I set up my notifications is pretty straightforward. In my AppDelegate, before didFinishLaunchingWithOptions, I set up the NotificationController (responsible for setting up and scheduling notifications), and then the NotificationDelegate (responsible for handling actions) as lazy vars so I can pass the NotificationController into the NotificationDelegate:

private lazy var notificationController =
    NotificationController(statsProvider: statsProvider)

private lazy var notificationDelegate: NotificationDelegate =
    NotificationDelegate(
        notificationController: notificationController
    )

Then, in didFinishLaunchingWithOptions, I set the delegate on UNUserNotificationCenter.current():

UNUserNotificationCenter.current().delegate = notificationDelegate

I don’t think any of the setup is abnormal, unless I’m missing something, so I can’t see how the nearly empty delegate method could still be crashing.


To illustrate the issue in isolation, I created a demo app to replicate the crash I'm seeing. I would like to know the proper way to use the userNotificationCenter(_:didReceive:) async delegate method without crashing.

This simple app sends a notification 5 seconds after it is launched. Tapping the notification to open the app is supposed to schedule another notification 5 seconds later, but the app crashes.

To replicate this, create a new iOS app project with UIKit and give it Push Notifications capability. Here is the AppDelegate:

import UIKit

@main
class AppDelegate: UIResponder, UIApplicationDelegate {

    private lazy var notificationController = NotificationController()

    private lazy var notificationDelegate = NotificationDelegate(
        notificationController: notificationController
    )

    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?)
        -> Bool
    {
        UNUserNotificationCenter.current().delegate = notificationDelegate

        return true
    }

    // MARK: UISceneSession Lifecycle

    func application(_ application: UIApplication,
                     configurationForConnecting connectingSceneSession: UISceneSession,
                     options: UIScene.ConnectionOptions)
    -> UISceneConfiguration
    {
        // Called when a new scene session is being created.
        // Use this method to select a configuration to create the new scene with.
        return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
    }
}

NotificationController:

import UserNotifications

class NotificationController {

    init()
    {
        Task {
            await setUpPermissions()
        }
    }

    func scheduleNotification()
    async
    {
        let content = UNMutableNotificationContent()
        content.body = "Test notification"
        content.sound = .default

        let trigger = UNTimeIntervalNotificationTrigger(
            timeInterval: 5,
            repeats: false
        )

        let request = UNNotificationRequest(
            identifier: "Test",
            content: content,
            trigger: trigger
        )

        do {
            try await UNUserNotificationCenter.current()
                .add(request)
        }
        catch {
            print(error.localizedDescription)
        }
    }

    private func setUpPermissions()
    async
    {
        let authorizationStatus = await UNUserNotificationCenter.current()
            .notificationSettings()
            .authorizationStatus
        
        switch authorizationStatus {

        case .notDetermined:

            do {
                let granted = try await UNUserNotificationCenter.current()
                    .requestAuthorization(options: [.sound, .alert])
                
                if granted
                {
                    await scheduleNotification()
                }
            }
            catch { print(error.localizedDescription) }
            
        case .authorized:

            await scheduleNotification()

        default:
            break
        }
    }
}

NotificationDelegate:

import UserNotifications

class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
    
    private let notificationController: NotificationController
    
    init(notificationController: NotificationController)
    {
        self.notificationController = notificationController
    }
    
    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                willPresent notification: UNNotification)
    async -> UNNotificationPresentationOptions
    {
        return [.banner, .sound]
    }
    
    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                didReceive response: UNNotificationResponse)
    async
    {
        print("didReceive response")
        
        if response.actionIdentifier == UNNotificationDefaultActionIdentifier
        {
            await notificationController.scheduleNotification()
        }
    }
}

Note that this even crashes without the attempt to schedule another notification:

func userNotificationCenter(_ center: UNUserNotificationCenter,
                            didReceive response: UNNotificationResponse)
async
{
    print("didReceive response")
}

How can this userNotificationCenter(_:didReceive:) async be used properly to avoid the crash?


Solution

  • I had the same exact issue.

    It looks like the assertion failure is caused by the method being called on a background thread.

    If you annotate the delegate method with @MainActor it fixes the issue:

    @MainActor
    public func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        didReceive response: UNNotificationResponse
    ) async {
        // implementation
    }