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?
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
}